Skip to content

Harden MCP portal authentication changes - #199

Open
dcartertwo wants to merge 1 commit into
feature/mcp-portal-large-catalogsfrom
feature/mcp-portal-auth-hardening
Open

Harden MCP portal authentication changes#199
dcartertwo wants to merge 1 commit into
feature/mcp-portal-large-catalogsfrom
feature/mcp-portal-auth-hardening

Conversation

@dcartertwo

@dcartertwo dcartertwo commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Portal authentication mode and deployment-owned tokens can change while an account or facet remains live. This makes auth-mode transitions clear stale OAuth state, refuses fallback/callback paths no longer allowed by current configuration, and invalidates captured connection generations when token authority changes.

OAuth redirects record their configuration revision before handing off to the callback, preventing an existing facet from invalidating the attempt while the user is authorizing.

Stacked on #170. Verified with the MCP and portal suites plus full pnpm lint.


Open in Devin Review

@github-actions github-actions Bot added mcp/shared Changes to shared MCP integration code gatekeeper Changes to a gatekeeper integration labels Aug 13, 2026
connected: ServerAuthKind, configured: ServerAuthKind,
): boolean {
return (connected === "token") !== (configured === "token");
return configured === "oauth" ? connected === "token" : connected !== configured;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc-comment above (lines 165-168) is now stale relative to this new logic. It says probing may legitimately move between none and OAuth (bidirectionally), but configured === "oauth" ? connected === "token" : connected !== configured only tolerates that drift when the portal is configured as oauth. When configured as none, a connected oauth state now requires a reconnect (connected !== configured) — the intended new hardening, but it contradicts the comment. Please update the doc to describe the asymmetric rule (an oauth-configured portal may prove public during probing, while an explicitly none-configured portal stays strict).

@ask-bonk

ask-bonk Bot commented Aug 13, 2026

Copy link
Copy Markdown

Review submitted with one inline finding.

I reviewed the PR (hardening MCP portal authentication). The logic is sound: the auth-mode reconnect rule, the OAuth fallback/callback gates, the connection-generation invalidation, and the per-revision in-flight counter all correctly implement "clear stale OAuth state / refuse paths no longer allowed / invalidate on token-authority change." The truth-table change to portalAuthRequiresReconnect flips exactly one case (connected=oauth, configured=none → now requires reconnect), which matches the stated intent, and the concurrency guard around #connectingRevisions and the baseline-revision logic in getConnection avoid clobbering a newer attempt. The new test validly exercises the revision-recording path.

I submitted 1 inline finding: the doc-comment on portalAuthRequiresReconnect in config.ts is now stale — it claims bidirectional none↔OAuth drift is fine, but the new implementation only tolerates that when the portal is configured oauth, and keeps none-configured strict.

github run

@dcartertwo
dcartertwo force-pushed the feature/mcp-portal-large-catalogs branch from 972434f to b4a5027 Compare August 14, 2026 00:35
@dcartertwo
dcartertwo force-pushed the feature/mcp-portal-auth-hardening branch from 2846cdc to f5c6473 Compare August 14, 2026 00:35
@ask-bonk

ask-bonk Bot commented Aug 14, 2026

Copy link
Copy Markdown

I've completed my review. My analysis confirms the logic is sound across all six files:

  • account.ts: The allowsOAuthFallback/allowsOAuthCallback hooks default to true (no regression for the generic connector), and authChanged only triggers when a caller passes a target with differing auth — which for user-supplied reconnects it never does (resolveConnectTarget passes existing). The generation/callback invalidation is guarded correctly.
  • portal.ts: The per-revision in-flight counter (#connectingRevisions) correctly prevents a losing/older concurrent attempt from clobbering a newer one's baseline. The getConnection migration branch invalidates a pre-revision token account exactly once and only invalidates oauth accounts' state when the revision genuinely changes.
  • config.ts: The portalAuthRequiresReconnect truth-table change (connected=oauth, configured=none now requires reconnect) matches the stated hardening.
  • Tests and the new vitest.config.ts alias (needed because portal.ts imports cloudflare:workers) are valid.

The only actionable issue is the stale doc-comment on portalAuthRequiresReconnect (config.ts:165-168), which claims bidirectional none↔OAuth drift is tolerated while the new asymmetric rule only tolerates it for oauth-configured portals. However, this exact finding was already submitted inline by the prior ask-bonk review on this same PR. Per review guidelines, I will not repeat a published finding, and I found no additional actionable problems.

No new actionable findings. (1 pre-existing finding — the stale portalAuthRequiresReconnect doc-comment — was already posted inline by an earlier review and is not repeated.)

github run

@ask-bonk

ask-bonk Bot commented Aug 14, 2026

Copy link
Copy Markdown

Now let me examine the authChanged interaction with the portal's allowsOAuthFallback more carefully for a subtle bug.

Consider a portal reconfigured from oauth to none:

  • On reconnect, target.auth = "none", existing.auth = "oauth".
  • authChanged = true → clears tokens, stores server with auth: "none".
  • allowsOAuthFallback(server) for portal returns server.auth !== "none"false.
  • Probe runs. If the server (still requiring auth) 401s, since allowsOAuthFallback is false, it throws "requires authorization but configured for unauthenticated access." Good.
  • If probe succeeds (public), records auth: "none". Good.

Now consider noneoauth:

  • target.auth = "oauth", existing.auth = "none". authChanged = true, clears state, stores server auth=oauth.
  • Probe unauthenticated. If 401, allowsOAuthFallback = "oauth" !== "none" = true → OAuth flows. Good.
  • If probe succeeds (public), records auth: "none" (line 378 sets {...server, auth:"none"} since not token). Hmm — so a portal configured oauth but actually public records auth: "none". Then portalAuthRequiresReconnect("none", "oauth") = false. Good, consistent.

The logic is coherent. Now let me double check one concern in getConnection: the #connectingRevisions Map is per-DO-instance in memory. If the DO evicts/restarts between a beginConnect redirect and the eventual getConnection, the map is empty. But that's fine because portalConfigRevision is persisted in storage during beginConnect, so previous === revision and no invalidation. The in-memory map is only an optimization for the concurrent-attempt window, which by nature is within one activation. Correct.

I've completed a thorough review. The implementation is sound. The only actionable finding is the stale doc-comment on portalAuthRequiresReconnect in config.ts (lines 165-168), which was already posted inline by a prior review on this PR. Per the review guidelines, I will not repeat a published finding, and I found no additional actionable issues.

No new actionable findings. (1 pre-existing finding — the stale portalAuthRequiresReconnect doc-comment at config.ts:165-168, which still claims bidirectional none↔OAuth drift is tolerated while the new asymmetric rule only tolerates it for oauth-configured portals — was already posted inline by an earlier review and is not repeated.)

github run

@dcartertwo
dcartertwo force-pushed the feature/mcp-portal-auth-hardening branch from f5c6473 to a6394d8 Compare August 18, 2026 19:38
@dcartertwo
dcartertwo force-pushed the feature/mcp-portal-large-catalogs branch from b4a5027 to 0ba0753 Compare August 18, 2026 19:38
@github-actions

Copy link
Copy Markdown

Preview: pr199-feature-mcp-p-151f663c

https://pr199-feature-mcp-p-151f663c-router.cloudflare-os-previews.workers.dev

Dashboard · deleted when this PR closes

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment on lines 336 to 345
this.ctx.storage.kv.delete(key);
}
this.ctx.storage.kv.put("expiredNotified", false);
this.log().info("portal repointed", {
event: "connect.repointed",
serverHost: hostOf(server.endpoint),
});
if (endpointChanged) {
this.log().info("portal repointed", {
event: "connect.repointed",
serverHost: hostOf(server.endpoint),
});
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Reconnecting a public portal can leave the account marked as needing a login it never had

The account's recorded sign-in mode is overwritten with the value from configuration (kv.put("server", server) at packages/mcp-shared/src/account.ts:332) before the endpoint has been contacted, so a portal that is actually open to everyone is briefly — or, if the contact attempt fails, permanently — recorded as requiring a login it has no credentials for.
Impact: While a user reconnects an open portal, in-flight tool calls fail with "this connection is not authorized" and the user is told their credentials expired; if the reconnect attempt errors out, the account stays broken until a later reconnect succeeds.

Auth-only change persists an unverified auth mode before the probe

Before this PR only endpointChanged caused the target record to be written ahead of the probe. authChanged (packages/mcp-shared/src/account.ts:330) now does the same. For the portal connector the target always carries the configured auth kind, while the stored record carries the observed one: a portal configured oauth whose endpoint answered publicly is stored with auth: "none" (packages/mcp-shared/src/account.ts:377-379). Every reconnect of such an account therefore sees existing.auth === "none" !== "oauth" and takes the branch, writing {...auth: "oauth"} and deleting tokens/oauthClient/oauthDiscovery before probe() runs.

During that await any facet call reaches #getAuthorization with server.auth === "oauth" and no stored tokens, which calls noteCredentialsExpired and throws "This MCP connection is not authorized" (packages/mcp-shared/src/account.ts:744-749). The portal's own getConnection guard does not stop it: portalAuthRequiresReconnect("oauth", "oauth") is false.

If the probe throws anything other than McpAuthRequiredError (transient network failure), restoreSelection re-opens the nonce but the stored server record is left at auth: "oauth", so the previously working public-portal account fails every subsequent call until a reconnect completes successfully.

(Refers to lines 330-345)

Prompt for agents
In McpAccountBase.beginConnect (packages/mcp-shared/src/account.ts), the new `authChanged` condition now persists the target ConnectedServer (including its configured `auth`) and wipes OAuth state before the endpoint has been probed. The stored record holds the *observed* auth mode (a portal configured `oauth` that answered publicly is stored as `none`, see the `connected` assignment later in the same method), while the target holds the *configured* mode, so `authChanged` is true on every reconnect of a public-but-oauth-configured portal. Two consequences: (1) during the probe await, concurrent facet calls hit `#getAuthorization` with `auth: "oauth"` and no tokens, which fires `noteCredentialsExpired` and throws "not authorized"; (2) if the probe fails with a non-auth error, the account is left recorded as `oauth` with no credentials, breaking a previously working account. Consider keeping the pre-probe `kv.put("server", ...)` limited to an endpoint repoint (the case that must fail closed for static tokens), and for an auth-only change either clear stale credential state without rewriting the recorded auth mode, or restrict the branch to transitions that actually change credential authority (to/from `token`, or oauth->none) rather than the `none`->`oauth` guess.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gatekeeper Changes to a gatekeeper integration mcp/shared Changes to shared MCP integration code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant